Repository navigation
fix(mcp): an action on a sys_* object whose author exposed it to AI is listed and run over MCP, behind every gate - #27940
Conversation
…ver MCP The runtime refused every action on a sys_* object by name at the three MCP sites (run_action, the list_actions bridge, the resume_run admission), ahead of the ai.exposed check, and the MCP tool layer hid and refused them again. Both now yield per action to the author's ai.exposed opt-in (ADR-0011); every gate behind it (ADR-0066 D4 capability, ADR-0126 activation, the param contract, ai.requiresConfirmation, the subject load) runs unchanged, and allowSystemObjects gains no reach. The bridge attests the opt-in to the tool layer as McpActionSummary.aiExposed. Claude-Session: https://claude.ai/code/session_01L6bhgTcRzav55DAtus77SU Co-authored-by: Claude <noreply@anthropic.com>
…transport Claude-Session: https://claude.ai/code/session_01L6bhgTcRzav55DAtus77SU Co-authored-by: Claude <noreply@anthropic.com>
…tion gate Claude-Session: https://claude.ai/code/session_01L6bhgTcRzav55DAtus77SU Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgTcRzav55DAtus77SU Co-authored-by: Claude <noreply@anthropic.com>
… flagged or not The sys_* name refusal also kept the MCP door from loading a stored-metadata row (a body carrying credential material) before the body-runner boundary; the per-action yield keeps that table family refused. Claude-Session: https://claude.ai/code/session_01L6bhgTcRzav55DAtus77SU Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 211531ed93958127e098a7ba78f9c1f90b5971a2 && git checkout 211531ed93958127e098a7ba78f9c1f90b5971a2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed0e71db94a73acc02512ff2ee0655106d02e585 91f77a3413217b9efdc539dc093b794c06eda396 && git checkout -B drift-repro ed0e71db94a73acc02512ff2ee0655106d02e585 && git merge --no-ff 91f77a3413217b9efdc539dc093b794c06eda396
node scripts/docs-audit/affected-docs.mjs --json ed0e71db94a73acc02512ff2ee0655106d02e585
|
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract-review-claim: session_01L6bhgUCizoYWQVJWjhdR6F · head 55f4e18 · 2026-10-30T04:44Z |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract reviewServed-tier: Ruling under review: 6513869839, letter B, maintainer 「同意」 at 2026-10-30T02:59Z, parameter 2 amended by 6513938013 (declarations to #27900), parameter 3 carried by #27908 (claim amendment 6514100238). Inputs: card #27681 and every comment, the PR body, file list, comments and net diff against ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Must-fix: none. Should-fix: (1) the two docs pages above; (2) file and name the ADR-0066 D4 follow-up card on the PR. Implemented-by: VERDICT: PASS |
…he family stays refused The two pages still stated the old rule (every sys_* action held back fail-closed). They now state the per-action rule: an action on a sys_* object is listed and run only on its own `ai.exposed: true`, behind every gate it already has; the stored-metadata tables stay refused even when flagged; and `list_actions` entries carry the `aiExposed` attestation. Claude-Session: https://claude.ai/code/session_01L6bhgTcRzav55DAtus77SU Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract reviewServed-tier: Delta review, carrying 6515376004 (PASS on ① Derived judgments
② Semver levelUnchanged from 6515376004: ③ Boundary flags
Must-fix: none. Should-fix: none — both should-fixes of 6515376004 are closed at this head (1 by commit Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Maintainer approval of the widening (recorded by the seat)This PR declares That reply answered the seat's request to approve this PR (cmsg_01D5bWFY3rFzgJcytD5gD4UuEK7UUg8LfHYxrErPefyiPe). The request named the change: an action on a This record carries that approval onto the PR. It is the maintainer's word, quoted. Head at approval: |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F 维护者速读(席位终稿)
席位意见: ACCEPT。忠于裁决 B,四处入口用同一个谓词,后续的门都没变,契约复核和增量复核都 PASS。 席位核对ACCEPT · head
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
Requested by Sales · project thread
Fixes #27681
Clause-②: yes (widening: MCP
list_actions/run_action/resume_runnow admit asys_*action that declaresai.exposed: true;McpActionSummarygains the optionalaiExposed)Metadata-protocol: none
Ruling-ref: 6513869839
维护者速读
sys_*)上的动作,只要作者在动作上声明了ai: { exposed: true, description },就能经 MCP 被列出和执行;未声明的仍按对象名拒绝。sys_*动作声明ai块,所以合入当天行为不变;存储元数据表(带凭据)照旧对 MCP 关闭;回滚即 revert 本 PR。权限、开关、确认、RLS 各闸照旧在后面跑。packages/runtime/src/action-execution.ts的systemObjectActionExposureError;packages/mcp/src/mcp-http-tools.ts的isListableAction与run_action预检;packages/runtime/src/domains/mcp.ts的 listActions / resume 两处。pnpm --filter @objectstack/runtime exec vitest run src/mcp-system-object-action-exposure.test.ts,预期 11 条全绿(未声明的被拒、声明的过每道闸、存储元数据族表照旧拒绝)。What changes
Maintainer ruling 6513869839 (letter B): a
sys_*action is reachable over MCP only when the action itself declaresai: { exposed: true, description }(ADR-0011). Every refusal keyed on the object-name prefix now yields to that flag, and to nothing else.The card named two guards. On the tree there are four sites, and all four now ask one predicate:
run_action(runtime)action-execution.tsinvokeBusinessActionisSystemObjectNamerefused everysys_*action, ahead of the exposure checksystemObjectActionExposureError: refuses only whenai.exposed !== truelist_actions(runtime bridge)domains/mcp.tslistActionssys_*object unconditionallyresume_runadmission (runtime bridge)domains/mcp.tsresumeAdmissionRefusalsys_*action, 403list_actions/run_action(tool layer)mcp-http-tools.tsallowSystemObjectsoff ⇒ hidden / refused by nameaiExposed: true;run_actionon an explicitsys_*objectNameis admitted only whenlist_actionswould list that action for this callerdomains/mcp.ts) had its own unconditionalsys_*skip in the listing, which the card did not name; without changing itlist_actionscould never show asys_*action. The seat added this file to the claim's surface (amendment 6514100238).resume_runis an inference beyond the ruling's text, stated here for the contract review.resume_runis documented as admitted "by the same gates asrun_action" (resumeAdmissionRefusal, MCPrun_actionon a screen flow dead-ends: the screen pauses even when every input is bound, andlist_actionsnever surfaces flow input variables #15705), and its ownsys_*refusal was a copy ofrun_action's. Leaving it name-keyed would let an agent start an exposedsys_*screen flow withrun_actionand then be refused on the screen it was handed. So it takes the same predicate. Nothing else in the resume gate changes.McpActionSummary.aiExposed(optional) is new. The tool layer never sees a declaration, so the bridge attests the author's opt-in on each listed summary; the runtime'ssummarizeActionsets it only whenai.exposed === true. A bridge that does not set it keeps everysys_*action hidden and refused at the tool layer, exactly as before (fail-closed for third-party bridges of the publishedregisterActionTools).stored-metadata-body-reads.test.ts([Decision] security(runtime): may an app-authored body still READ the stored-metadata family's tables, served projected, or is that refused too (#21454 option C) #21594) pins that the MCP door never reaches the subject load forsys_metadata/sys_metadata_history, whose rows hold stored metadata bodies with credential material, even for anai.exposedaction. The ruling opened identity actions and named no family table, so the predicate keeps everyisStoredMetadataBodyObjecttable refused before the subject load ("…stores metadata bodies and is never exposed via MCP — read and change those through the metadata API"). This is the one place the predicate does not yield to the flag; opening it would be its own decision.allowSystemObjectsstays MCP-side, default false. It opens the tool layer's filter only; the runtime predicate does not read it, so even with it on an undeclaredsys_*action is refused by the runtime.ai.requiresConfirmationgate, the subject load under the caller's RLS, and for atype: 'api'action the target endpoint's own authorization. The order of the gates ininvokeBusinessActionis unchanged.sys_*action gains anaiblock here; the declarations follow decision card [Decision] sys_* kernel objects, assessed as one set: which of the 58 platform identity actions may an agent ever reach (plain declaration / declaration + confirmation / human-only), executing #27681 B parameter 2 in one ruling #27900 (amendment 6513938013). The dogfood case uses a test-only declaration set for one boot.console.info[action-audit]line on the script arm (which already namesMCP run_actionvsREST action); theapi,flowand declarative-update arms write none.sys_audit_logrows are the data writes an action body performs, with no surface column, andExecutionContexthas no surface key (performedByis set only at the OAuth/mcpdoor and is lost across the in-process api forward, which re-authenticates). A durable surface field needspackages/spec(an attribution key), the audit writer and the forward's headers, which is outside this card's lane. A log line is not offered as the audit row.91f77a34, docs only):content/docs/ai/actions-as-tools.mdxandcontent/docs/ai/connect-mcp.mdxstated the old rule, thatsys_*actions are held back fail-closed. They now give the per-action rule: only an action that declaresai.exposed: trueitself is admitted; the stored-metadata family stays refused; every later gate is unchanged.list_actionsitems documentaiExposed: true. The docs families derived for these two paths andpnpm docs:buildall exit 0 on91f77a34. This came from contract-review should-fix 1 (6515376004); claim amendment 6515405353.HITL confirmation over MCP, measured before any edit
Wired end to end on
origin/maine320e366: therun_actiontool declares and forwardsconfirm(mcp-http-tools.ts), the bridge passes the whole request through (domains/mcp.tsrunAction), andinvokeBusinessActioncallsactionConfirmationRefusalafter the param contract and before the subject read and the api forward, answering 428ACTION_CONFIRMATION_REQUIRED. Two properties the reviewer should keep in view, both pre-existing and unchanged here: the gate reads only the DECLAREDai.requiresConfirmation: true(the listing'srequiresConfirmationheuristic does not gate), andconfirm: trueis a caller claim (decision batch #54 accepted that model: the gate makes forgetting loud, it does not prove a human). The dogfood case below shows the 428 on asys_useraction over the live transport.Tests
packages/runtime/src/mcp-system-object-action-exposure.test.ts(new, 11): the predicate, including the stored-metadata family staying refused when flagged; listing lists flagged and hides unflagged, D4 still filters the listing;run_actionrefuses the unflagged action with and withoutobjectNameand dispatches nothing; the flagged one runs, and is refused by D4, by the activation switch (409ACTION_DISABLED) and by the confirmation gate (428) before dispatch, then runs confirmed.packages/runtime/src/mcp-resume-run.test.ts(+2): on asys_*object a flagged flow action's run resumes; an unflagged one's run is refused 403 with the system-object refusal and the engine is not asked.packages/mcp/src/mcp-action-system-object-exposure.test.ts(new, 8, throughMCPServerRuntime.handleHttpRequest): attested listed / unattested hidden; a bridge that attests nothing stays closed;allowSystemObjects: truekeeps its meaning;run_actionrefuses an unattested, an unlisted and a same-named-other-object action before the bridge runs, admits the attested one; an ordinary object is not screened.packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts(new, 6, showcase + better-auth admin + live/mcp):ban_userwith a test-onlyai: { exposed: true, requiresConfirmation: true };list_actionslists it (and notunban_user);unban_userrefused by name with and withoutobjectName; unconfirmedban_user428 and the user stays unbanned; switched off ⇒ 409 even confirmed; a plain member's confirmed call refused; the confirmed admin call bans.packages/runtime/src/http-dispatcher.test.ts: the standalonesys_api_keyrow pinned as refused carriedai: { exposed: true }; the ruling reverses exactly that, so the fixture is now unflagged (the flagged case lives in the new file). A moved pin, named here for review.packages/runtime/src/action-execution-destructive.test.ts: the "gate 1" pin moved fromisSystemObjectNameto the per-action predicate (the 13 identity actions are excluded because none declaresai.exposed).Acceptance notes
claude/project-thread-qid1f1, notclaude/issue-27681-*.run_actioncall on an explicitsys_*objectNamenow reads the bridge's listing once before running. It is the tool layer's only view of the declaration; calls on ordinary objects are not affected.sys_*action the caller cannot see in its own listing (capability missing, handler unbound) is refused at the tool layer with the system-object sentence, which names both conditions, rather than with the runtime's narrower reason.Verification
Ablations (each red, then restored to the HEAD blob)
Run with
scripts/ablation-replace.mjs, every leg committed first; the dogfood leg went throughpackages/runtime/dist(ablation-dist-preflight.mjsmarker present on the mutation leg,--absentafter the rebuild).ai.exposedinvokeBusinessActionguard deletedaiExposedrun_actionpre-check removedsummarizeActionstops attestingaiExposeddist, dogfoodAll on head
55f4e186(the code head;origin/mainmerged once at709dba37). The final head91f77a34adds the docs-only commit above and no code.pnpm --filter @objectstack/runtime test: 481 files, 7842 passed, 19 skipped, 0 failed. (The first full run, before the stored-metadata fix, was 3 red: the two [Decision] security(runtime): may an app-authored body still READ the stored-metadata family's tables, served projected, or is that refused too (#21454 option C) #21594 family pins and the standalonesys_api_keypin; that run is what found the family boundary.)pnpm --filter @objectstack/mcp test: 46 files, 594 passed.pnpm --filter @objectstack/mcp typecheckandpnpm --filter @objectstack/runtime typecheck(both withcheck:test-typecheck): exit 0.mcp-system-object-action-exposure.dogfood.test.ts+api-action-door-forward.dogfood.test.ts: 14/14. The full battery is not owed (no!, no(narrowing)).pnpm exec eslint --no-inline-config packages/runtime/src packages/mcp/srcplus the new dogfood file: exit 0, no findings.dispatch-gates.mjs --commands --repo objectstack-ai/objectstack): 65 derived, 65 run, every exit 0;--ranreconcile: 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsneeded eight dists built first (a prerequisite, not a verdict) andcheck:type-check-debtwas rerun under the verify lock.git merge-treeagainst the open PRs whose diffs touch these packages (fix(runtime): a forwarded api action keeps the target's whole refusal, and decision outputs pass the param check #27915, feat(rest,console): one request opens a record page #27713, fix(console): a declared same-origintype: 'api'click goes through the action door #27793): all clean; only fix(runtime): a forwarded api action keeps the target's whole refusal, and decision outputs pass the param check #27915 shares a file (action-execution.ts), in a different region.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F